diff options
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/settings')
11 files changed, 539 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx new file mode 100644 index 0000000..468f250 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx @@ -0,0 +1,6 @@ +'use client'; +import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage'; + +export function SettingsPage({ websiteId }: { websiteId: string }) { + return <WebsiteSettingsPage websiteId={websiteId} />; +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx new file mode 100644 index 0000000..21cd613 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx @@ -0,0 +1,104 @@ +import { Button, Column, Dialog, DialogTrigger, Modal } from '@umami/react-zen'; +import { ActionForm } from '@/components/common/ActionForm'; +import { + useLoginQuery, + useMessages, + useModified, + useNavigation, + useUserTeamsQuery, +} from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { WebsiteDeleteForm } from './WebsiteDeleteForm'; +import { WebsiteResetForm } from './WebsiteResetForm'; +import { WebsiteTransferForm } from './WebsiteTransferForm'; + +export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) { + const { formatMessage, labels, messages } = useMessages(); + const { user } = useLoginQuery(); + const { touch } = useModified(); + const { router, pathname, teamId, renderUrl } = useNavigation(); + const { data: teams } = useUserTeamsQuery(user.id); + const isAdmin = pathname.startsWith('/admin'); + + const canTransferWebsite = + ( + (!teamId && + teams?.data?.filter(({ members }) => + members.find( + ({ role, userId }) => + [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, + ), + )) || + [] + ).length > 0 || + (teamId && + !!teams?.data + ?.find(({ id }) => id === teamId) + ?.members.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id)); + + const handleSave = () => { + touch('websites'); + onSave?.(); + router.push(renderUrl(`/websites`)); + }; + + const handleReset = async () => { + onSave?.(); + }; + + return ( + <Column gap="6"> + {!isAdmin && ( + <ActionForm + label={formatMessage(labels.transferWebsite)} + description={formatMessage(messages.transferWebsite)} + > + <DialogTrigger> + <Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button> + <Modal> + <Dialog title={formatMessage(labels.transferWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteTransferForm websiteId={websiteId} onSave={handleSave} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + )} + + <ActionForm + label={formatMessage(labels.resetWebsite)} + description={formatMessage(messages.resetWebsiteWarning)} + > + <DialogTrigger> + <Button>{formatMessage(labels.reset)}</Button> + <Modal> + <Dialog title={formatMessage(labels.resetWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + + <ActionForm + label={formatMessage(labels.deleteWebsite)} + description={formatMessage(messages.deleteWebsiteWarning)} + > + <DialogTrigger> + <Button data-test="button-delete" variant="danger"> + {formatMessage(labels.delete)} + </Button> + <Modal> + <Dialog title={formatMessage(labels.deleteWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteDeleteForm websiteId={websiteId} onSave={handleSave} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx new file mode 100644 index 0000000..2fc0276 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx @@ -0,0 +1,40 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; + +const CONFIRM_VALUE = 'DELETE'; + +export function WebsiteDeleteForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/websites/${websiteId}`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('websites'); + touch(`websites:${websiteId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={error} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx new file mode 100644 index 0000000..4ae819e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx @@ -0,0 +1,55 @@ +import { Form, FormButtons, FormField, FormSubmitButton, TextField } from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks'; +import { DOMAIN_REGEX } from '@/lib/constants'; + +export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) { + const website = useWebsite(); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('websites'); + touch(`website:${website.id}`); + onSave?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={website}> + <FormField name="id" label={formatMessage(labels.websiteId)}> + <TextField data-test="text-field-websiteId" value={website?.id} isReadOnly allowCopy /> + </FormField> + <FormField + label={formatMessage(labels.name)} + data-test="input-name" + name="name" + rules={{ required: formatMessage(labels.required) }} + > + <TextField /> + </FormField> + <FormField + label={formatMessage(labels.domain)} + data-test="input-domain" + name="domain" + rules={{ + required: formatMessage(labels.required), + pattern: { + value: DOMAIN_REGEX, + message: formatMessage(messages.invalidDomain), + }, + }} + > + <TextField /> + </FormField> + <FormButtons> + <FormSubmitButton data-test="button-submit" variant="primary"> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx new file mode 100644 index 0000000..d791bc9 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx @@ -0,0 +1,37 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +const CONFIRM_VALUE = 'RESET'; + +export function WebsiteResetForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync, isPending, error } = useUpdateQuery(`/websites/${websiteId}/reset`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={error} + buttonLabel={formatMessage(labels.reset)} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx new file mode 100644 index 0000000..3970cdb --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx @@ -0,0 +1,28 @@ +import { Column } from '@umami/react-zen'; +import { Panel } from '@/components/common/Panel'; +import { useWebsite } from '@/components/hooks'; +import { WebsiteData } from './WebsiteData'; +import { WebsiteEditForm } from './WebsiteEditForm'; +import { WebsiteShareForm } from './WebsiteShareForm'; +import { WebsiteTrackingCode } from './WebsiteTrackingCode'; + +export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { + const website = useWebsite(); + + return ( + <Column gap="6"> + <Panel> + <WebsiteEditForm websiteId={websiteId} /> + </Panel> + <Panel> + <WebsiteTrackingCode websiteId={websiteId} /> + </Panel> + <Panel> + <WebsiteShareForm websiteId={websiteId} shareId={website.shareId} /> + </Panel> + <Panel> + <WebsiteData websiteId={websiteId} /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx new file mode 100644 index 0000000..99977a0 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx @@ -0,0 +1,22 @@ +import { IconLabel, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; +import { ArrowLeft, Globe } from '@/components/icons'; + +export function WebsiteSettingsHeader() { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { renderUrl } = useNavigation(); + + return ( + <> + <Row marginTop="6"> + <Link href={renderUrl(`/websites/${website.id}`)}> + <IconLabel icon={<ArrowLeft />} label={formatMessage(labels.website)} /> + </Link> + </Row> + <PageHeader title={website?.name} description={website?.domain} icon={<Globe />} /> + </> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx new file mode 100644 index 0000000..56c6f43 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx @@ -0,0 +1,93 @@ +import { + Button, + Column, + Form, + FormButtons, + FormSubmitButton, + IconLabel, + Label, + Row, + Switch, + TextField, +} from '@umami/react-zen'; +import { RefreshCcw } from 'lucide-react'; +import { useState } from 'react'; +import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks'; +import { getRandomChars } from '@/lib/generate'; + +const generateId = () => getRandomChars(16); + +export interface WebsiteShareFormProps { + websiteId: string; + shareId?: string; + onSave?: () => void; + onClose?: () => void; +} + +export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const [currentId, setCurrentId] = useState(shareId); + const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); + const { cloudMode } = useConfig(); + + const getUrl = (shareId: string) => { + if (cloudMode) { + return `${process.env.cloudUrl}/share/${shareId}`; + } + + return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`; + }; + + const url = getUrl(currentId); + + const handleGenerate = () => { + setCurrentId(generateId()); + }; + + const handleSwitch = () => { + setCurrentId(currentId ? null : generateId()); + }; + + const handleSave = async () => { + const data = { + shareId: currentId, + }; + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch(`website:${websiteId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}> + <Column gap> + <Switch isSelected={!!currentId} onChange={handleSwitch}> + {formatMessage(labels.enableShareUrl)} + </Switch> + {currentId && ( + <Row alignItems="flex-end" gap> + <Column flexGrow={1}> + <Label>{formatMessage(labels.shareUrl)}</Label> + <TextField value={url} isReadOnly allowCopy /> + </Column> + <Column> + <Button onPress={handleGenerate}> + <IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} /> + </Button> + </Column> + </Row> + )} + <FormButtons justifyContent="flex-end"> + <Row alignItems="center" gap> + {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>} + <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton> + </Row> + </FormButtons> + </Column> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx new file mode 100644 index 0000000..d24f948 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx @@ -0,0 +1,40 @@ +import { Column, Label, Text, TextField } from '@umami/react-zen'; +import { useConfig, useMessages } from '@/components/hooks'; + +const SCRIPT_NAME = 'script.js'; + +export function WebsiteTrackingCode({ + websiteId, + hostUrl, +}: { + websiteId: string; + hostUrl?: string; +}) { + const { formatMessage, messages, labels } = useMessages(); + const config = useConfig(); + + const trackerScriptName = + config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME; + + const getUrl = () => { + if (config?.cloudMode) { + return `${process.env.cloudUrl}/${trackerScriptName}`; + } + + return `${hostUrl || window?.location?.origin || ''}${ + process.env.basePath || '' + }/${trackerScriptName}`; + }; + + const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl(); + + const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`; + + return ( + <Column gap> + <Label>{formatMessage(labels.trackingCode)}</Label> + <Text color="muted">{formatMessage(messages.trackingCode)}</Text> + <TextField value={code} isReadOnly allowCopy asTextArea resize="none" /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx new file mode 100644 index 0000000..8af4f05 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx @@ -0,0 +1,102 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + Loading, + Select, + Text, +} from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { + useLoginQuery, + useMessages, + useUpdateQuery, + useUserTeamsQuery, + useWebsite, +} from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function WebsiteTransferForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { user } = useLoginQuery(); + const website = useWebsite(); + const [teamId, setTeamId] = useState<string>(null); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`); + const { data: teams, isLoading } = useUserTeamsQuery(user.id); + const isTeamWebsite = !!website?.teamId; + + const items = + teams?.data?.filter(({ members }) => + members.some( + ({ role, userId }) => + [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, + ), + ) || []; + + const handleSubmit = async () => { + await mutateAsync( + { + userId: website.teamId ? user.id : undefined, + teamId: website.userId ? teamId : undefined, + }, + { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + const handleChange = (key: Key) => { + setTeamId(key as string); + }; + + if (isLoading) { + return <Loading icon="dots" placement="center" />; + } + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={{ teamId }}> + <Text> + {formatMessage( + isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam, + )} + </Text> + <FormField name="teamId"> + {!isTeamWebsite && ( + <Select onSelectionChange={handleChange} selectedKey={teamId}> + {items.map(({ id, name }) => { + return ( + <ListItem key={`${id}`} id={`${id}`}> + {name} + </ListItem> + ); + })} + </Select> + )} + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton + variant="primary" + isPending={isPending} + isDisabled={!isTeamWebsite && !teamId} + > + {formatMessage(labels.transfer)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx new file mode 100644 index 0000000..a26d14f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SettingsPage } from './SettingsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <SettingsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Settings', +}; |